Skip to content

Transpose the whole track, or one lane at a time, on one clock - #479

Merged
thcp merged 3 commits into
mainfrom
feat/pitch-transpose
Aug 27, 2026
Merged

Transpose the whole track, or one lane at a time, on one clock#479
thcp merged 3 commits into
mainfrom
feat/pitch-transpose

Conversation

@thcp

@thcp thcp commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Transposition, per-lane and global, plus two things found on the way.

Closes #245
Closes #476
Closes #477
Closes #478

Transpose (#245, #476)

A semitone control in the transport bar moves the whole track. One on
every mixer lane moves that instrument alone, so a harmony line can be
practised against the original melody rather than instead of it.

Drums are never transposed, and that is enforced in three places
because there are three ways in: the UI never offers it, the engine
ignores it if a caller asks anyway, and the worklet's unpitched input is
where drums are wired regardless. It also covers drums you cannot see.
A stem deselected at import is folded into original.wav, so
playbackStems.js rebuilds that lane from the retained raw stems and
routes its drums out. A job too old to rebuild keeps the whole lane
unpitched rather than risk resampling a kit mixed into it.

One clock, not one shifter per lane

The obvious build does not work. WSOLA latency moves with the shift, so
independently clocked chains put lanes up to 144 ms apart:

pitch  0, tempo 1       0 ms   (bypass)
pitch +2, tempo 1     115 ms
pitch -5, tempo 1     144 ms
pitch  0, tempo 0.75  437 ms

So the worklet takes one input per semitone, each a pitch chain, summed
into a single tempo stage. A lane's key is which input it is connected
to
, not a parameter. Measured with five chains live at once:

Worst pitch error 0.35 cents
Worst skew between buses 12 ms
Drift over 60 s 0.0 ms
Semitone 0 bit-identical bypass

Changing a lane mid-playback neither restarts nor flushes. Every bus is
primed with the same silence, so the old chain plays out what it
buffered while the new one takes over as it runs dry. A short duck, not
a gap.

Absolute lane keys

A lane shows the key it is actually in, and the global control applies
its change rather than its value. So a lane deliberately put a third
above the rest stays a third above the rest when the track moves, and
there is never a second number to add in your head. Reset returns the
global key and every lane at once, which stepping the global back to
zero cannot do once lanes have moved individually.

Three things the measurements changed

  • An earlier revision split attacks out of the music bus and passed them
    at unity while the rest went through the pitch chain. A delayed copy
    plus an undelayed copy cannot sum back to the input: it put a 0.64
    full-scale step into the output at every onset, against a source whose
    own largest step was 0.10. Heard as static. Removed.
  • The chain produced exactly one quantum per quantum with no slack, so a
    quantum occasionally came up short and was emitted zero-padded. A hard
    edge to zero mid-block is a click. Fixed with an output reserve and an
    input cushion banked during priming.
  • The old alignment test reported 2 ms, but only because the attack
    splitter was passing its probe through the bypass. Real alignment was
    18 ms and one-sided, being the WSOLA sequence sawtooth. Predictable,
    so now cancelled rather than tolerated.

Lane meters (#477)

They were linear in amplitude. Loudness is logarithmic and a separated
stem sits around -20 to -30 dBFS, so normal playback lived in the bottom
tenth of the bar. Measured median across a real library: 10.4%, with
drums and "other" under 2%.

Now dB-scaled with a -60 dBFS floor:

stem (median) before after
drums, quiet mix 1.7% 27.8%
other, quiet mix 1.6% 26.9%
vocals 5.0% 43.4%
bass 34.1% 71.1%

This forced the meters off getByteTimeDomainData: its quantisation
step, one part in 128, is -42 dBFS, which on a dB scale would light
every meter to a third of full over silence.

Windows test harness (#478)

tests/e2e/serve.sh is stored LF with no .gitattributes, so a
Windows checkout wrote it CRLF and bash could not run a single line of
it. The browser suite was unrunnable on the primary desktop development
platform, and CI never saw it because CI is Linux.

Testing

suite
Browser (Playwright) 66 pass
DSP 21 pass
Engine routing 43 pass
VU scale 14 pass
Other JS suites pass
i18n coverage and duplicates clean, 9 tables plus the ptPT variant

Two of these deserve a note. tests/js/pitch-shift.test.mjs includes a
control that rebuilds the worklet with the input cushion removed and
confirms the click gate fails, so it can actually catch the regression
it exists for. tests/js/vu-scale.test.mjs asserts what fraction of the
bar real measured stem levels light and keeps the old linear formula as
a control, so the comparison is a measurement rather than a claim.

Not covered here

tests/e2e/serve.sh runs uv run, which resynced a project environment
on a GPU box, uninstalled seven packages and tried to replace a
hand-installed torch 2.6.0+cu124 with the plain wheel. It failed
partway on a locked DLL and rolled back, so nothing was lost. Noted in
#478 as worth its own issue; not fixed here.

No Python dependencies changed, so the desktop in-app updater is
unaffected.

Before merging: the Unraid template pin in templates/stemdeck.xml is
still 0.15.2 and is a separate decision.

Thales added 3 commits August 27, 2026 18:18
tests/e2e/serve.sh is stored LF but there was no .gitattributes, so a
checkout with core.autocrlf=true (the Windows default) wrote it out with
CRLF. bash treats the trailing CR as part of each command, so `npx
playwright test` could not start its web server at all and the whole
suite was unrunnable locally.

CI never saw this because it runs on Linux, where autocrlf is off. The
practical effect was that the browser tests got skipped on the primary
desktop development platform and were assumed to pass.

Pinning *.sh to eol=lf fixes it for a fresh clone rather than for one
working copy; running dos2unix on the file works until the next checkout.
The file itself is renormalised here so existing clones pick it up
without waiting for a checkout to touch it.

Closes #478
The per-lane VU meters were linear in amplitude, min(1, rms * 2.5).
Loudness is logarithmic and a separated stem sits around -20 to -30 dBFS,
so normal playback lived in the bottom tenth of every bar and the top
three quarters was decoration.

Measured across four tracks of a real library, median RMS per window
through the old scale:

    vocals   5.0%    drums   1.7%    bass  5.2%    other  1.6%
    vocals  20.5%    drums   8.5%    bass 34.1%    other  2.8%
    mean median 10.4%

A -60 dBFS floor mapped onto the bar is the usual choice for a small
console meter: quiet passages still register and full scale stays
reachable without being reached constantly. The same stems now read
27.8%, 26.9%, 43.4% and 71.1%.

This forced the meters off getByteTimeDomainData. Its quantisation step,
one part in 128, is -42 dBFS, which on any dB scale with a lower floor
would light every meter to about a third of full over silence.

The scale lives in its own module because player.js cannot be imported
outside a browser, and a scale nothing can measure is how the linear
version survived this long. tests/js/vu-scale.test.mjs asserts what
fraction of the bar real measured stem levels light, and keeps the old
formula as a control so the comparison is a measurement rather than a
claim about the past. It guards the other direction too: no clipping to
full, headroom preserved, and a stem 20 dB down still reads 20 dB down.

Closes #477
Adds a semitone control to the transport bar and one to every mixer
lane. The transport control moves the whole track; a lane control moves
that instrument alone, so a harmony line can be practised against the
original melody rather than instead of it.

Drums are never transposed. A resampled snare is not the same drum in
another key, it is a different drum. That refusal is enforced in three
places because there are three ways in: the UI never offers it, the
engine ignores it if a caller asks anyway, and the worklet's unpitched
input is where drums are wired regardless. It also covers drums you
cannot see: a stem deselected at import is folded into original.wav, so
playbackStems.js rebuilds that lane from the retained raw stems and
routes the drums out of it. When a job predates those stems and cannot
be rebuilt, the whole lane stays unpitched rather than risk resampling a
kit that is mixed into it.

One shared clock, not one shifter per lane. WSOLA latency moves with the
shift, so independently clocked chains put lanes up to 144 ms apart from
each other:

    pitch  0, tempo 1       0 ms   (bypass)
    pitch +2, tempo 1     115 ms
    pitch -5, tempo 1     144 ms
    pitch  0, tempo 0.75  437 ms

So the worklet takes one input per semitone, each a pitch chain, and
sums them into a single tempo stage. A lane's key is which input it is
connected to, not a parameter. Measured with five chains live at once:
0.35 cents worst pitch error, 12 ms worst skew between buses, no drift
over a minute.

Changing a lane mid-playback neither restarts nor flushes anything.
Every bus is primed with the same amount of silence, so the lane's old
chain plays out what it had buffered while the new one takes over as it
runs dry. The handover is a short duck, not a gap.

Lane keys are absolute rather than offsets from the global control, and
the global control applies its change rather than its value. So the
number on a lane is always the key that lane is in, and a lane
deliberately put a third above the rest stays a third above the rest
when the whole track moves. A reset returns the global key and every
lane at once, which stepping the global back to zero cannot do.

Three things the measurements changed along the way:

  - An earlier revision split attacks out of the music bus and passed
    them at unity while the rest went through the pitch chain. A delayed
    copy plus an undelayed copy cannot sum back to the input, and it put
    a 0.64 full-scale step into the output at every onset against a
    source whose own largest step was 0.10. Heard as static. Removed.
  - The chain produced exactly one quantum per quantum with no slack, so
    a quantum occasionally came up short and was emitted zero-padded. A
    hard edge to zero mid-block is a click. Fixed with an output reserve
    and an input cushion banked during priming.
  - The old alignment test reported 2 ms, but only because the attack
    splitter was passing its probe through the bypass. Real alignment
    was 18 ms and one-sided, being the WSOLA sequence sawtooth. It is
    predictable, so it is now cancelled rather than tolerated.

The lane row is about 300px and was already full, so the meter column
gives up 18px to keep the fader usable, and the meters take that as
height: 5px read as a hairline rather than a meter.

Closes #245
Closes #476
@thcp
thcp merged commit 07f051a into main Aug 27, 2026
10 checks passed
@thcp
thcp deleted the feat/pitch-transpose branch August 27, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant